summaryrefslogtreecommitdiff
path: root/app/[lng]/partners/data-room/[projectId]/stats/page.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/[lng]/partners/data-room/[projectId]/stats/page.tsx')
-rw-r--r--app/[lng]/partners/data-room/[projectId]/stats/page.tsx373
1 files changed, 373 insertions, 0 deletions
diff --git a/app/[lng]/partners/data-room/[projectId]/stats/page.tsx b/app/[lng]/partners/data-room/[projectId]/stats/page.tsx
new file mode 100644
index 00000000..7f652a99
--- /dev/null
+++ b/app/[lng]/partners/data-room/[projectId]/stats/page.tsx
@@ -0,0 +1,373 @@
+// app/projects/[projectId]/stats/page.tsx
+'use client';
+
+import { use, useState, useEffect } from 'react';
+import {
+ BarChart3,
+ TrendingUp,
+ HardDrive,
+ Users,
+ Eye,
+ Download,
+ Upload,
+ Calendar,
+ FileText,
+ FolderOpen,
+ Activity
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Badge } from '@/components/ui/badge';
+import { Progress } from '@/components/ui/progress';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+
+interface ProjectStats {
+ storage: {
+ used: number;
+ limit: number;
+ fileCount: number;
+ folderCount: number;
+ byCategory: {
+ public: number;
+ restricted: number;
+ confidential: number;
+ internal: number;
+ };
+ };
+ activity: {
+ views: number;
+ downloads: number;
+ uploads: number;
+ shares: number;
+ trend: number; // 증감률
+ };
+ users: {
+ total: number;
+ active: number;
+ byRole: {
+ admin: number;
+ editor: number;
+ viewer: number;
+ };
+ };
+ recent: {
+ type: string;
+ user: string;
+ action: string;
+ timestamp: string;
+ details: string;
+ }[];
+}
+
+export default function ProjectStatsPage({
+ params
+}: {
+ params: Promise<{ projectId: string }>
+}) {
+ // Next.js 15에서 params를 unwrap
+ const resolvedParams = use(params);
+ const projectId = resolvedParams.projectId;
+
+ const [stats, setStats] = useState<ProjectStats | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [dateRange, setDateRange] = useState('30d');
+ const { toast } = useToast();
+
+ useEffect(() => {
+ fetchStats();
+ }, [projectId, dateRange]);
+
+ const fetchStats = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(
+ `/api/projects/${projectId}/stats?range=${dateRange}`
+ );
+
+ if (!response.ok) {
+ if (response.status === 403) {
+ throw new Error('통계를 볼 권한이 없습니다');
+ }
+ throw new Error('통계 로드 실패');
+ }
+
+ const data = await response.json();
+ setStats(data);
+ } catch (error: any) {
+ toast({
+ title: '오류',
+ description: error.message || '통계를 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatBytes = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ const formatNumber = (num: number) => {
+ return new Intl.NumberFormat('ko-KR').format(num);
+ };
+
+ if (loading) {
+ return (
+ <div className="p-6">
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+ {[...Array(8)].map((_, i) => (
+ <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" />
+ ))}
+ </div>
+ </div>
+ );
+ }
+
+ if (!stats) {
+ return (
+ <div className="p-6 text-center">
+ <BarChart3 className="h-12 w-12 mx-auto mb-3 text-muted-foreground" />
+ <p className="text-muted-foreground">통계를 불러올 수 없습니다</p>
+ </div>
+ );
+ }
+
+ const storagePercentage = (stats.storage.used / stats.storage.limit) * 100;
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">프로젝트 통계</h1>
+ <p className="text-muted-foreground mt-1">
+ 프로젝트 사용 현황과 활동 내역을 확인합니다
+ </p>
+ </div>
+
+ <Tabs value={dateRange} onValueChange={setDateRange}>
+ <TabsList>
+ <TabsTrigger value="7d">7일</TabsTrigger>
+ <TabsTrigger value="30d">30일</TabsTrigger>
+ <TabsTrigger value="90d">90일</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ </div>
+
+ {/* 주요 지표 */}
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">스토리지 사용량</CardTitle>
+ <HardDrive className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatBytes(stats.storage.used)}
+ </div>
+ {/* <Progress value={storagePercentage} className="mt-2" /> */}
+ {/* <p className="text-xs text-muted-foreground mt-1">
+ 총 {formatBytes(stats.storage.limit)} 중 {storagePercentage.toFixed(1)}% 사용
+ </p> */}
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">파일 수</CardTitle>
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatNumber(stats.storage.fileCount)}
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 폴더 {formatNumber(stats.storage.folderCount)}개 포함
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">활성 사용자</CardTitle>
+ <Users className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {stats.users.active}
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 전체 {stats.users.total}명 중
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">총 다운로드</CardTitle>
+ <Download className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatNumber(stats.activity.downloads)}
+ </div>
+ <div className="flex items-center gap-1 mt-1">
+ {stats.activity.trend > 0 ? (
+ <TrendingUp className="h-3 w-3 text-green-500" />
+ ) : (
+ <TrendingUp className="h-3 w-3 text-red-500 rotate-180" />
+ )}
+ <span className={cn(
+ "text-xs",
+ stats.activity.trend > 0 ? "text-green-500" : "text-red-500"
+ )}>
+ {Math.abs(stats.activity.trend)}%
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 상세 통계 */}
+ <div className="grid gap-6 md:grid-cols-2">
+ {/* 파일 카테고리 분포 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>파일 카테고리</CardTitle>
+ <CardDescription>카테고리별 파일 분포</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-green-500 rounded-full" />
+ <span className="text-sm">Public</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.public}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-yellow-500 rounded-full" />
+ <span className="text-sm">Restricted</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.restricted}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-red-500 rounded-full" />
+ <span className="text-sm">Confidential</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.confidential}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-blue-500 rounded-full" />
+ <span className="text-sm">Internal</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.internal}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 활동 요약 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>활동 요약</CardTitle>
+ <CardDescription>기간별 활동 내역</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Eye className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">조회수</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.views)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Download className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">다운로드</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.downloads)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Upload className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">업로드</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.uploads)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Users className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">공유</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.shares)}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+{/* 최근 활동 */}
+<Card>
+ <CardHeader>
+ <CardTitle>최근 활동</CardTitle>
+ <CardDescription>프로젝트 내 최근 활동 내역</CardDescription>
+ </CardHeader>
+
+ {/* 패딩이 스크롤에 포함되도록 CardContent p-0 + 내부 래퍼에 패딩 */}
+ <CardContent className="p-0">
+ <div
+ className="max-h-80 md:max-h-96 xl:max-h-[480px] overflow-y-auto px-6 pb-6"
+ style={{ scrollbarGutter: "stable" }} // 스크롤바 생겨도 레이아웃 흔들림 방지
+ aria-label="최근 활동 스크롤 영역"
+ tabIndex={0} // 키보드 포커스 가능
+ >
+ <ul role="list" className="divide-y">
+ {stats.recent.map((activity, index) => (
+ <li key={index} className="flex items-center gap-3 py-3">
+ <Activity className="h-4 w-4 text-muted-foreground shrink-0" />
+ <div className="min-w-0 flex-1">
+ <p className="text-sm">
+ <span className="font-medium">{activity.user}</span>
+ {" "}님이{" "}
+ <span className="font-medium">{activity.details}</span>
+ {activity.action === "upload" && "을(를) 업로드했습니다"}
+ {activity.action === "download" && "을(를) 다운로드했습니다"}
+ {activity.action === "view" && "을(를) 조회했습니다"}
+ {activity.action === "share" && "을(를) 공유했습니다"}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {new Date(activity.timestamp).toLocaleString()}
+ </p>
+ </div>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </CardContent>
+</Card>
+ </div>
+ );
+} \ No newline at end of file